总结
- 使用 OSS 云存储服务,有两点可继续优化
- 上传时间优化:按需上传,对比文件,文件未变更,无需上传。并发控制,批量上传。
- 云空间优化:旧资源剔除,节省云空间。
- 按需上传判断:
- 资源不在 OSS :需要上传
- 对于
hash
资源:如果存在 OSS 中,不需要上传 - 对于不带
hash
资源:添加元数据即自定义头部,比对标志(mtime),不同则需要上传。
- 不带
hash
资源对比标志为什么选用mtime
(修改时间)- 对比的目的:确认 资源的内容 是否更新,这跟响应头
Etag
的设计一致。 - 所以可以参考
Etag
的实现,nginx 使用的是资源的mtime
和size
,即修改时间和资源大小,去计算得到Etag
。mtime
只能作用于秒级的改变,1s 内如果有重复更改,mtime
会保持不变。所以需要size
去辅助计算,减小未覆盖场景。但 nginx 的Etag
还是有个场景未覆盖到:1s 内多次更改内容,且更改后资源大小保存一致。 - 我们上传的是打包完的静态资源,打包时间不可能在 1s 内,不会发生 1s 内重复更改资源的情况,所以只用
mtime
就够了,mtime
改变意味着内容改变。同时也要 webpack5 的持久化缓存,不对未更改的内容重新编译,文件不会被覆盖,mtime
也不会更改。
- 对比的目的:确认 资源的内容 是否更新,这跟响应头
- 并发控制:一个一个资源串行上传太耗时,可以引入
p-queue
并行上传,优化上传时间。 - Rclone:Go 语言编写的一款高性能云文件同步的命令行工具,Go 语言编译出来是一个二进制文件,性能比 js 这种解释型语言快很多。且使用
obsutil
去编写脚本还是太繁琐了,Rclone
可以帮我们更好去操作,性能更好。 - 云空间优化:删除 OBS 中冗余资源。
- 非多版本并存的情况下,可以对比云空间和本地资源,如果属于多余资源则删除。
- 多版本并存情况下,则通过打包路径
output.path
输出到不同路径区分不同版本,对比云空间和本地资源,如果属于多余资源则删除。
- 为什么不在上传资源后,同步进行删除冗余资源的操作:
部署到线上生效有一段时间,且可能因为用户的缓存问题,会访问到这些旧资源,所以删除操作不要过急。删除冗余资源的目的是节省空间,但优先级低,低于用户体验。 - 定时任务周期性删除 OSS 上的冗余资源:使用
CRON
,linux 上的定时任务。sh# CRON:Linux 定时任务,在 /etc/crontab 配置。 # 格式: * * * * * command # 分钟(0-59) 小时(0-23) 日期(1-31) 月份(1-12) 星期(0-6,0代表星期天) 命令 # 每晚的 02:00 执行脚本 0 2 * * * /usr/local/bin/node /xx/deleteOBS.mjs
1. 对象存储优化
当公司内将一个静态资源部署云服务的前端项目持续跑了 N 年后,部署了上万次后,可能出现几种情况。
- 时间过长:如构建后的资源全部上传到对象存储,然而有些资源内容并未发生变更,将会导致过多的上传时间。
- 冗余资源:前端每改一行代码,便会生成一个新的资源,而旧资源将会在 OSS 不断堆积,占用额外体积。 从而导致更多的云服务费用。
2. 静态资源上传优化: 按需上传与并发控制
在前端构建过程中存在无处不在的缓存
- 当源文件内容未发生更改时,将不会对
Module
重新使用Loader
等进行重新编译。这是利用了 webpack5 的持久化缓存。 - 当源文件内容未发生更改时,构建生成资源的
hash
将不会发生变更。此举有利于 HTTP 的Long Term Cache
(强缓存)。
2.1 对比
通过对比,如果未改变则不向 OSS 进行上传操作。这一步将会提升静态资源上传时间,进而提升每一次前端部署的时间。
对比依据:
- 资源不在 OBS 直接返回 false
- 对于
hash
资源:如果存在 OBS 中,直接返回 true - 对于不带
hash
资源:比对唯一标志(mtime)。
通过阿里云或华为云的 node SDK
,进行操作。华为云 npm install esdk-obs-nodejs
文档
/* 自定义文件标记,使用文件的 ctime 和 size */
const getHash = stats => encodeURI(`${stats.ctimeMs}-${stats.size}`)
/**
* 判断文件 (Object)是否在 OBS 中存在
* 文件不在 OBS 直接返回 false
* 对于 hash,如果存在 OBS 中,直接返回 true
* 对于非 hash,比对唯一标志(mtime,size)
* @param {Object} entry 文件信息,带有 stats 对象
* @param {String} Key 文件路径
* @param {Boolean} withHash 该文件名是否携带 hash 值
*/
async function isExistObject ({entry, Key, withHash}) {
try {
const res = await client.getObjectMetadata({
Bucket,
Key
})
if(res.CommonMsg.Status === 404) { // 文件不存在
return false
}
if (withHash) { // hash 提前返回
return true
}
// 非 hash 存在的情况下 判断文件的 元数据-自定义响应头
const {Metadata = {}} = res.InterfaceResult
const {hash = ''} = Metadata
return hash === getHash(entry.stats)
} catch (e) {
return false
}
}
2.2 按需上传、设置缓存策略
根据有无 hash
设置对应的缓存策略。
/**
* 上传文件,设置对应缓存策略
* @param {String} objectName 文件路径
* @param {Boolean} withHash 该文件名是否携带 hash 值
*/
async function uploadFile ({entry, withHash = false}) {
let Key = entry.path.replace(/\\/g, '/') // 兼容windows
if(withHash) {
Key = `static/${Key}`
}
const file = resolve('./build', Key)
// 判断文件是否存在
const exist = await isExistObject({entry, Key, withHash})
if (!exist) {
// 设置缓存策略
const CacheControl = withHash ? 'max-age=31536000' : 'no-cache'
try {
// 为了加速传输速度,这里使用 stream
/* 上传对象 */
let res = await client.putObject({
Bucket,
Key,
// 创建文件流,其中 file 为待上传的本地文件路径,需要指定到具体的文件名
Body: createReadStream(file),
})
if(res.CommonMsg.Status !== 200) {
throw res
}
/* 设置对象元数据 */
const Metadata = {
hash: getHash(entry.stats)
}
res = await client.setObjectMetadata({
Bucket,
Key,
MetadataDirective: 'REPLACE_NEW',
CacheControl,
Metadata
})
if(res.CommonMsg.Status !== 200) {
throw res
}
console.log(`Done: ${Key}`)
} catch(err) {
console.log(`${err.CommonMsg?.Message}:${Key}`)
}
} else {
// 如果该文件在 OBS 已存在,则跳过该文件 (Object)
console.log(`Skip: ${Key}`)
}
}
2.3 并发控制
可以通过 p-queue 控制资源上传的并发数量
import PQueue from 'p-queue'
import readdirp from 'readdirp'
const queue = new PQueue({ concurrency: 10 }) // 线程池 10
async function main() {
// 首先上传不带 hash 的文件
for await (const entry of readdirp('./build', { depth: 0, type: 'files', alwaysStat: true })) {
queue.add(() => uploadFile({entry}))
}
// 上传携带 hash 的文件
for await (const entry of readdirp('./build/static', { type: 'files', alwaysStat: true })) {
queue.add(() => uploadFile({entry, withHash: true}))
}
}
3. Rclone: 按需上传
使用 obsutil 去编写脚本还是太繁琐了,Rclone 可以帮我们更好去操作,且性能更好。
Rclone,rsync for cloud storage
,是使用 Go 语言编写的一款高性能云文件同步的命令行工具,可理解为云存储版本的 rsync
,或者更高级的 ossutil
。
它支持以下功能:
- 按需复制,每次仅仅复制更改的文件
- 断点续传
- 压缩传输
3.1 下载安装
Windows 和 Mac 可以直接下载exe,下载列表
Docker中引入 参照文档 https://rclone.org/install/#install-with-docker
3.2 初始化
我个人使用的是华为 obs 作为云存储,参考文档 https://rclone.org/s3/#huawei-obs
首先执行 rclone config
,进行初始化,按照文档进行配置,主要留意 type 为 s3,其 provider 为 HuaweiOBS。整个设置流程提示很充足,比较简单。
3.3 上传
# 将资源上传到 OBS Bucket
# obs: 通过 rclone 配置的云服务名称,此处为华为的 obs,个人取名为 obs
# bucketName: oss 中的 bucket 名称
$ rclone copy --exclude 'static/**' --header 'Cache-Control: no-cache' build obs:/bucketName --progress
# 将带有 hash 资源上传到 OBS Bucket,并且配置长期缓存
$ rclone copy --header 'Cache-Control: max-age=31536000' build/static obs:/bucketName/static --progress
为了方便,将两条命令维护到 npm scripts
中
{
"scripts": {
"obs:rclone": "rclone copy --exclude 'static/**' --header 'Cache-Control: no-cache' build obs:/bucketName --progress && rclone copy --header 'Cache-Control: max-age=31536000' build/static obs:/bucketName/static --progress",
}
}
4. 删除 OBS 中冗余资源
在生产环境中,OBS 只需保留最后一次线上环境所依赖的资源。(多版本共存情况下除外) 此时可根据 OBS 中所有资源与最后一次构建生成的资源一一对比文件名,进行删除。
// 列举出来最新被使用到的文件: 即当前目录
// 列举出来OBS上的所有文件,遍历判断该文件是否在当前目录,如果不在,则删除
async function main() {
const files = await getCurrentFiles()
const objects = await getAllObjects()
for (const object of objects) {
// 如果当前目录中不存在该文件,则该文件可以被删除
if (!files.includes(object.name)) {
await client.delete(object.name)
console.log(`Delete: ${object.name}`)
}
}
}
命令维护到 npm scripts
{
"scripts": {
"obs:prune": "node scripts/deleteOBS.mjs"
}
}
而对于清除任务可通过定时任务周期性删除 OSS
上的冗余资源,比如通过 CRON
配置每天凌晨两点进行删除。由于该脚本定时完成,所以无需考虑性能问题,故不适用 p-queue
进行并发控制。
CRON:Linux 定时任务,在
/etc/crontab
配置。
格式: * * * * * command
分钟(0-59) 小时(0-23) 日期(1-31) 月份(1-12) 星期(0-6,0代表星期天) 命令
每晚的 02:00 执行脚本
0 2 * * * /usr/local/bin/node deleteOBS.mjs
生产环境发布了多个版本的前端,如 AB 测试,toB 面向不同大客户的差异化开发与部署,此时可针对不同版本对应不同的 output.path
打包输出到不同路径来解决。
output.path 可通过环境变量注入 webpack 选项,而环境变量可通过以下命令置入。(或置入 .env)
export COMMIT_SHA=$(git rev-parse --short HEAD)
export COMMIT_REF_NAME=$(git branch --show-current)
export COMMIT_REF_NAME=$(git rev-parse --abbrev-ref HEAD)
疑问
遗留
提问
- [x] 使用 rclone 上传文件至 oss
- [x] 针对你们项目静态资源的存储及上传做了那些优化 资源存储再云存储服务 oss 上,通过按需上传和并发控制优化上传速度,按需上传的判断标准是:oss 没有的资源,直接上传。oss hash 资源已有,不上传,oss 非 hash 资源已有,则判断元数据 mtime,不一样则上传。且通过定时任务,对比云服务上的资源和本地资源,将冗余资源删除。